本节会对Java中与线程和同步相关的陷阱进行介绍。
Thread.stop Thread.resume Thread.suspendJava线程相关API中最严重的问题是对java.lang.Thread类中stop resume 和suspend方法的调用。这些方法从Java 1.0版本中就存在,但很快就被发现存在问题,并且不推荐再使用。尽管如此,还是为时已晚,尽管已经发出警告,对这些方法的调用至今仍散落在世界各地的历史遗留代码中,而且不少新近的应用程序也仍在使用,本书的作者曾见到过2008年开发的代码中还在使用这些方法。
stop方法用于终止线程的执行,但却并不安全,这是因为如果该线程正在修改全局数据,那么终止线程可能会使数据不一致,破坏应有状态。接收到终止信号的线程会释放其持有的锁,使正在被修改的数据对其他线程可见,这违反了Java的沙箱模型。
因此,普遍建议使用wait方法、notify方法或volatile变量来做线程间的同步处理。
使用suspend方法挂起一个线程可能会产生死锁,即如果线程 T1获取了锁对象 L1,但却被挂起了,此时另一个线程 T2试图获取锁 L1就会被阻塞住,直到线程 T1重新恢复执行并释放该锁。但如果负责调用resume方法来唤醒线程 T1的线程 T3也想获取锁 L1,于是乎线程 T3也会被阻塞住,这时就形成了死锁。因此,Thread.resume方法和Thread.suspend方法也因为过于危险而被弃用。
因此,永远不要使用Thread.stop方法、Thread.resume方法或Thread.syspend方法,并小心处理历史遗留代码中对这些方法的使用。
如果对内存模型和CPU架构缺乏理解的话,即使使用平台独立性很高的Java做开发一样会遇到问题。以下面的代码为例,其目的是实现单例模式:
public class GadgetHolder {
private Gadget theGadget;
public synchronized Gadget getGadget() {
if (this.theGadget == null) {
this.theGadget = new Gadget();
}
return this.theGadget;
}
}
上面的代码是线程安全的,因为getGadget方法是同步的,以自身实例作为隐式监视器。但当Gadget类的构造函数已经执行过一次之后,再执行同步操作看起来有些浪费,因此,为了优化性能,将之改造为下面的代码:
public Gadget getGadget() {
if (this.theGadget == null) {
synchronized(this) {
if (this.theGadget == null) {
this.theGadget = new Gadget();
}
}
}
return this.theGadget;
}
上面的代码使用了一个看起来很"聪明"的技巧,如果对象已经存在,则将之返回,不再执行同步操作,而是直接返回已有的对象,如果对象还未创建,则进入同步代码块,创建对象并赋值。这样可以保证 "线程安全"。
上面代码的就是所谓的 双检查锁(double checked locking),下面分析一下这段代码的问题。假设某个线程经过内层的空值检查,开始初始化theGadget字段的值,该线程需要为新对象分配内存,并对theGadget字段赋值。可是,这一系列操作并不是原子的,且执行顺序无法保证。如果在此时正好发生线程上下文切换,则另一个线程看到的theGadget字段的值可能是未经完整初始化的,有可能会导致外层的控制检查失效,并返回这个未经完整初始化的对象。不仅仅是创建对象可能会出问题,处理其他类型数据时也要小心。例如,在32位平台上,写入一个long型数据通常需要执行2次32位数据的写操作,而写入int数据则无此顾虑。
上述问题可以通过将theGadget字段声明为volatile来绕过(注意,只在新版本的内存模型下才有效),不过却会增加执行开销。尽管比使用synchronized方法小,但还是有的。为清楚起见,如果不缺点当前版本的内存模型是否实现正确的话。不要使用双检查锁。网上有很多文章介绍了为什么不应该使用双检查锁,不仅限于Java,其他语言也是。
双检查锁的危险之处在于,在强内存模型下,它很少会使程序崩溃。Intel IA-64平台就是个典型,其弱内存模型臭名远扬,原本好好运行的Java应用程序可能不知到啥时就出问题了。如果某个应用程序在x86平台运行良好,在x64平台却出问题,人们很容易怀疑是JVM的bug,却忽视了有可能是Java应用程序自身的问题。
使用静态域来实现单例模式可以实现同样的语义,而无需使用双检查锁,如下所示:
public class GadgetMaker {
public static Gadget theGadget = new Gadget();
}
Java语言保证类的初始化是原子操作,由于GadgetMaker类中没有其他的域,因此,在首次引用(译者注,这里应该是"主动引用","被动引用"并不会执行类的初始化)该类时会自动创建Gadget类的实例。并赋值给theGadget字段。这种方法在新旧两种内存模型下均可正常工作。
译者注:
- "主动引用"和"被动引用"的说法参见周志明编写的《深入理解Java虚拟机》。
- Java语言规范中定义了在何种情况下才会执行类的初始化,如下:
- 若
T是一个类,则创建T时会执行类的初始化- 若
T是一个类,则调用由T声明的静态方法会执行类的初始化- 对类型
T声明的静态域赋值会执行类的初始化- 访问类型
T声明的常量不会执行类的初始化- 如果
T是一个顶层类(top level class),并且内嵌其中的断言语句被执行,则会执行类的初始化- 通过
java.lang.reflect包中类和java.lang.Class类以反射的方式调用- 除上述情况外,其他情况均不会触发类的初始化,相关验证参见gist
使用Java做并行程序开发有很多需要小心的地方,如果能够正确理解Java内存模型,那么是可以避开这些陷阱的。进一步说,开发人员往往不太关心当前的硬件架构,但如果不能理解内存模型的话,就迟早会搬起石头砸自己的脚。